@codeyam-editor/codeyam-editor 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud.js +116 -23
- package/npm/fleet-add-failure.js +59 -12
- package/npm/fleet-memory.js +29 -4
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-vm-access.js +162 -0
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +43 -0
- package/npm/scenario-check.js +11 -1
- package/npm/scenario-handlers.js +18 -0
- package/npm/scenario-issues.js +11 -0
- package/npm/scenario-mocks.js +245 -39
- package/npm/scenario-playwright.js +70 -12
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-DSVZvEkB.js} +1 -1
- package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
- package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
- package/ui/dist/assets/{index-VuyqBbGJ.js → index-BPLKgwon.js} +9 -9
- package/ui/dist/assets/index-DKpgJLo3.css +1 -0
- package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
- package/ui/dist/assets/index-CjxWhkSy.css +0 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
package/npm/cloud.js
CHANGED
|
@@ -59,6 +59,7 @@ const {
|
|
|
59
59
|
normalizePrincipal,
|
|
60
60
|
resolveDefaultOperator,
|
|
61
61
|
withDefaultOperator,
|
|
62
|
+
unauthorizableAddRefusal,
|
|
62
63
|
accessFor,
|
|
63
64
|
setAccess,
|
|
64
65
|
addAccess,
|
|
@@ -3608,8 +3609,9 @@ function fleetInstancePrefix() {
|
|
|
3608
3609
|
* scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
|
|
3609
3610
|
* result so callers decide fatality — Add treats a non-zero as a loud warning
|
|
3610
3611
|
* (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
|
|
3611
|
-
* teardown still removes the instance.
|
|
3612
|
-
|
|
3612
|
+
* teardown still removes the instance. `vmAccess` (up only) is the Add's intended
|
|
3613
|
+
* principal set, which the script's grant step uses in place of its sidecar read. */
|
|
3614
|
+
function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
|
|
3613
3615
|
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3614
3616
|
const inv = vmIngressInvocation({
|
|
3615
3617
|
scriptPath,
|
|
@@ -3621,6 +3623,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
|
3621
3623
|
appPort,
|
|
3622
3624
|
instancePrefix: fleetInstancePrefix(),
|
|
3623
3625
|
slug,
|
|
3626
|
+
vmAccess,
|
|
3624
3627
|
});
|
|
3625
3628
|
return spawnSync(inv.command, inv.args, {
|
|
3626
3629
|
stdio: "inherit",
|
|
@@ -3651,17 +3654,77 @@ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
|
|
|
3651
3654
|
});
|
|
3652
3655
|
}
|
|
3653
3656
|
|
|
3657
|
+
/** THE principal set that must have IAP access to this VM after the Add: the
|
|
3658
|
+
* explicit `cfg.vmAccess` collaborators unioned with the resolved default
|
|
3659
|
+
* operator. Both add-time access paths consume this ONE function —
|
|
3660
|
+
* `wireVmIngressOnAdd` (which hands it to vm-ingress.sh's grant) and
|
|
3661
|
+
* `applyVmAccessOnAdd` (which grants it and subtracts it from the stale-revoke
|
|
3662
|
+
* set). They used to decide independently, and that is precisely how one path
|
|
3663
|
+
* granted `user:jared@codeyam.com` at 00:09:31 and the other revoked the same
|
|
3664
|
+
* principal 18 seconds later as a "stale prior tenant" (VMs 4/5/6, 2026-08-09):
|
|
3665
|
+
* the ingress path read the SIDECAR (still holding the prior tenant) while
|
|
3666
|
+
* `applyVmAccessOnAdd` resolved an empty operator. One source of truth removes
|
|
3667
|
+
* the whole class of disagreement. `env` is injectable for tests; the only I/O
|
|
3668
|
+
* is `resolveDefaultOperator`'s config/gcloud read. */
|
|
3669
|
+
function intendedVmPrincipals(cfg = {}, env = process.env) {
|
|
3670
|
+
return withDefaultOperator(
|
|
3671
|
+
normalizeAccessList(cfg.vmAccess || []),
|
|
3672
|
+
resolveDefaultOperator({ env }),
|
|
3673
|
+
);
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
/** Add preflight: throw — BEFORE any GCE instance exists — when this Add would
|
|
3677
|
+
* come up on an IAP-gated public URL that authorizes NOBODY. The whole condition
|
|
3678
|
+
* is env + config (`intendedVmPrincipals` needs no GCP state), which is why it
|
|
3679
|
+
* belongs beside cmdUp's other cheap boundary validations rather than after a
|
|
3680
|
+
* machine has been provisioned: checking later means a half-provisioned VM to
|
|
3681
|
+
* clean up. On 2026-08-09 the un-refused version of this Add reported `done` for
|
|
3682
|
+
* VMs 4/5/6, left three unreachable URLs, and revoked each slot's prior tenant on
|
|
3683
|
+
* the way.
|
|
3684
|
+
*
|
|
3685
|
+
* Only fires in LB mode on a numbered fleet VM with nothing to grant; an explicit
|
|
3686
|
+
* `--vm-access` satisfies it with no default operator configured, so the
|
|
3687
|
+
* collaborator flow is unaffected, as is every legacy quick-tunnel Add. The
|
|
3688
|
+
* refusal text (and its `Next valid action:` remedy naming all three config
|
|
3689
|
+
* tiers) is the pure `unauthorizableAddRefusal`; this function is the composition
|
|
3690
|
+
* and the throw. */
|
|
3691
|
+
function assertAddCanAuthorizeSomeone(cfg, env = process.env) {
|
|
3692
|
+
let plan;
|
|
3693
|
+
try {
|
|
3694
|
+
plan = ingressWiringPlan(cfg, env);
|
|
3695
|
+
} catch {
|
|
3696
|
+
// A malformed domain is `resolveIngressDomain`'s error to raise on the wiring
|
|
3697
|
+
// path; it must not masquerade here as an authorization refusal.
|
|
3698
|
+
plan = { wire: false };
|
|
3699
|
+
}
|
|
3700
|
+
const refusal = unauthorizableAddRefusal({
|
|
3701
|
+
lbMode: !!plan.wire,
|
|
3702
|
+
vmNumber: plan.wire ? plan.vmNumber : null,
|
|
3703
|
+
intendedCount: plan.wire ? intendedVmPrincipals(cfg, env).length : null,
|
|
3704
|
+
});
|
|
3705
|
+
if (refusal) throw new Error(refusal);
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3654
3708
|
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
3655
3709
|
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
3656
3710
|
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
3657
3711
|
* prints the exact re-run command rather than throwing. No-op (and silent) in
|
|
3658
|
-
* legacy quick-tunnel mode or for a non-fleet client project.
|
|
3712
|
+
* legacy quick-tunnel mode or for a non-fleet client project.
|
|
3713
|
+
*
|
|
3714
|
+
* RETURNS the principals this call granted (`intendedVmPrincipals`), or [] when
|
|
3715
|
+
* it did not wire — the explicit dataflow `applyVmAccessOnAdd` needs to know what
|
|
3716
|
+
* the Add has already granted before it computes a stale-revoke. Returned on the
|
|
3717
|
+
* FAILURE path too: a non-zero `vm-ingress.sh up` may still have completed its
|
|
3718
|
+
* grant step, and a principal that was possibly granted must never be revoked. */
|
|
3659
3719
|
function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
3660
3720
|
const plan = ingressWiringPlan(cfg, env);
|
|
3661
|
-
if (!plan.wire) return;
|
|
3721
|
+
if (!plan.wire) return [];
|
|
3662
3722
|
const label = plan.slug || `vm-${plan.vmNumber}`;
|
|
3723
|
+
// Hand the intended set to the script so its `apply_default_iap_access` grants
|
|
3724
|
+
// THIS Add's principals instead of reading the sidecar's prior tenant.
|
|
3725
|
+
const vmAccess = intendedVmPrincipals(cfg, env);
|
|
3663
3726
|
console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
|
|
3664
|
-
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
|
|
3727
|
+
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug, vmAccess);
|
|
3665
3728
|
if (res.status === 0) {
|
|
3666
3729
|
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
3667
3730
|
} else {
|
|
@@ -3683,6 +3746,7 @@ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
|
3683
3746
|
`(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
|
|
3684
3747
|
);
|
|
3685
3748
|
}
|
|
3749
|
+
return vmAccess;
|
|
3686
3750
|
}
|
|
3687
3751
|
|
|
3688
3752
|
/** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
|
|
@@ -3751,8 +3815,15 @@ function runIapAccess(cfg, vmNumber, action, principals) {
|
|
|
3751
3815
|
* a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
|
|
3752
3816
|
* command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
|
|
3753
3817
|
* array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
|
|
3754
|
-
* list (the clean-slate case).
|
|
3755
|
-
|
|
3818
|
+
* list (the clean-slate case).
|
|
3819
|
+
*
|
|
3820
|
+
* `grantedThisRun` is what an EARLIER step of this same Add already granted —
|
|
3821
|
+
* `wireVmIngressOnAdd`'s return value. It is subtracted from the stale-revoke
|
|
3822
|
+
* set, so a principal this Add just authorized can never be revoked moments later
|
|
3823
|
+
* as a prior tenant. That is a second, cheaper guard than the shared
|
|
3824
|
+
* `intendedVmPrincipals` resolver above: it holds even if the two paths drift
|
|
3825
|
+
* apart again, which is exactly how VMs 4/5/6 broke on 2026-08-09. */
|
|
3826
|
+
function applyVmAccessOnAdd(cfg, env = process.env, { grantedThisRun = [] } = {}) {
|
|
3756
3827
|
const desired = normalizeAccessList(cfg.vmAccess || []);
|
|
3757
3828
|
let plan;
|
|
3758
3829
|
try {
|
|
@@ -3769,11 +3840,15 @@ function applyVmAccessOnAdd(cfg, env = process.env) {
|
|
|
3769
3840
|
// never inaccessible-by-default (the Error-51 footgun). The default operator is
|
|
3770
3841
|
// NOT persisted to the sidecar (it's a standing grant, not a per-VM
|
|
3771
3842
|
// collaborator), so `desired` alone drives the sidecar + stale-revoke below.
|
|
3772
|
-
const toGrant =
|
|
3843
|
+
const toGrant = intendedVmPrincipals(cfg, env);
|
|
3773
3844
|
const stale = accessFor(vmNumber);
|
|
3774
3845
|
// Never revoke a principal we're about to (re-)grant — a prior tenant who was
|
|
3775
|
-
// also the default operator must not be revoked-then-regranted for nothing
|
|
3776
|
-
|
|
3846
|
+
// also the default operator must not be revoked-then-regranted for nothing —
|
|
3847
|
+
// NOR one an earlier step of this same Add already granted. `toGrant` alone
|
|
3848
|
+
// protected nothing when it was empty: the ingress step had granted the operator
|
|
3849
|
+
// seconds earlier and this revoke took it straight back off (VMs 4/5/6).
|
|
3850
|
+
const grantedAlready = new Set([...toGrant, ...normalizeAccessList(grantedThisRun || [])]);
|
|
3851
|
+
const staleOnly = stale.filter((p) => !grantedAlready.has(p));
|
|
3777
3852
|
|
|
3778
3853
|
if (plan.wire) {
|
|
3779
3854
|
if (staleOnly.length > 0) {
|
|
@@ -3890,12 +3965,23 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3890
3965
|
}
|
|
3891
3966
|
if (!plan.wire) {
|
|
3892
3967
|
// Nothing to converge — the no-op wire keeps the clean-slate invariant.
|
|
3893
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3894
|
-
return { wired: true, reason: "no-wire" };
|
|
3895
|
-
}
|
|
3896
|
-
|
|
3968
|
+
const granted = wireVmIngressOnAdd(cfg, appPort, env);
|
|
3969
|
+
return { wired: true, reason: "no-wire", grantedThisRun: granted };
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
// The intended set, resolved ONCE and handed to every `up` attempt so each
|
|
3973
|
+
// converge iteration grants the same principals — and reported back to the
|
|
3974
|
+
// caller as `grantedThisRun` so the access reconcile that follows cannot revoke
|
|
3975
|
+
// them. Every `up` runs the script's grant step, so these are granted whether
|
|
3976
|
+
// the loop converges or hits its deadline. Injectable via `deps` like the rest
|
|
3977
|
+
// of this loop's slow I/O: the resolve spawns `gcloud config get-value account`
|
|
3978
|
+
// on its last tier, and a unit test must never depend on the gcloud identity
|
|
3979
|
+
// that happens to be active on the machine running it.
|
|
3980
|
+
const grantedThisRun =
|
|
3981
|
+
deps.grantedThisRun != null ? deps.grantedThisRun : intendedVmPrincipals(cfg, env);
|
|
3897
3982
|
const runUp =
|
|
3898
|
-
deps.runUp
|
|
3983
|
+
deps.runUp
|
|
3984
|
+
|| ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug, grantedThisRun));
|
|
3899
3985
|
const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
|
|
3900
3986
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3901
3987
|
const now = deps.now || (() => Date.now());
|
|
@@ -3938,7 +4024,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3938
4024
|
if (decision.done) {
|
|
3939
4025
|
if (decision.wired) {
|
|
3940
4026
|
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
3941
|
-
return { wired: true, reason: "wired", attempts: attempt + 1 };
|
|
4027
|
+
return { wired: true, reason: "wired", attempts: attempt + 1, grantedThisRun };
|
|
3942
4028
|
}
|
|
3943
4029
|
const rewire =
|
|
3944
4030
|
`FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
|
|
@@ -3955,7 +4041,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3955
4041
|
`CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
|
|
3956
4042
|
`within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
|
|
3957
4043
|
);
|
|
3958
|
-
return { wired: false, reason: "deadline", attempts: attempt + 1 };
|
|
4044
|
+
return { wired: false, reason: "deadline", attempts: attempt + 1, grantedThisRun };
|
|
3959
4045
|
}
|
|
3960
4046
|
attempt += 1;
|
|
3961
4047
|
await sleep(decision.delayMs);
|
|
@@ -3984,8 +4070,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
|
3984
4070
|
if (!plan.wire) {
|
|
3985
4071
|
// Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
|
|
3986
4072
|
// sidecar write. Run them inline so the clean-slate invariant still holds.
|
|
3987
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3988
|
-
applyVmAccessOnAdd(cfg, env);
|
|
4073
|
+
const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4074
|
+
applyVmAccessOnAdd(cfg, env, { grantedThisRun });
|
|
3989
4075
|
return;
|
|
3990
4076
|
}
|
|
3991
4077
|
const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
|
|
@@ -4017,8 +4103,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
|
4017
4103
|
`running it inline instead.`,
|
|
4018
4104
|
);
|
|
4019
4105
|
try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
|
|
4020
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
4021
|
-
applyVmAccessOnAdd(cfg, env);
|
|
4106
|
+
const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4107
|
+
applyVmAccessOnAdd(cfg, env, { grantedThisRun });
|
|
4022
4108
|
}
|
|
4023
4109
|
}
|
|
4024
4110
|
|
|
@@ -4050,8 +4136,11 @@ async function cmdWireAddTail() {
|
|
|
4050
4136
|
// Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
|
|
4051
4137
|
// `up` left to the slow reconcile sweep — then the IAP access grant, in the
|
|
4052
4138
|
// SAME order cmdUp ran them inline.
|
|
4053
|
-
|
|
4054
|
-
|
|
4139
|
+
// Carry what the converge granted into the access reconcile, so the revoke
|
|
4140
|
+
// step cannot take back a principal this same Add just authorized — the exact
|
|
4141
|
+
// grant-then-revoke-18s-later sequence `/tmp/cy-wire-add-vm-4.log` recorded.
|
|
4142
|
+
const { grantedThisRun } = await convergeVmIngressOnAdd(cfg, appPort);
|
|
4143
|
+
applyVmAccessOnAdd(cfg, process.env, { grantedThisRun });
|
|
4055
4144
|
} finally {
|
|
4056
4145
|
if (marker) {
|
|
4057
4146
|
try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
|
|
@@ -4163,6 +4252,8 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4163
4252
|
}
|
|
4164
4253
|
}
|
|
4165
4254
|
|
|
4255
|
+
assertAddCanAuthorizeSomeone(cfg);
|
|
4256
|
+
|
|
4166
4257
|
checkScriptDrift(cfg);
|
|
4167
4258
|
|
|
4168
4259
|
await ensureFirewallRule(cfg);
|
|
@@ -5320,6 +5411,8 @@ module.exports = {
|
|
|
5320
5411
|
ingressWiringPlan,
|
|
5321
5412
|
fleetInstancePrefix,
|
|
5322
5413
|
runVmIngress,
|
|
5414
|
+
intendedVmPrincipals,
|
|
5415
|
+
assertAddCanAuthorizeSomeone,
|
|
5323
5416
|
wireVmIngressOnAdd,
|
|
5324
5417
|
probeVmIngressVerdict,
|
|
5325
5418
|
convergeVmIngressOnAdd,
|
package/npm/fleet-add-failure.js
CHANGED
|
@@ -99,10 +99,13 @@ function transcriptRostered(logTail) {
|
|
|
99
99
|
// The categories that describe an add whose outcome is NOT yet proven bad — the
|
|
100
100
|
// calm, in-flight side of the classifier. `converging` is a rostered box whose
|
|
101
101
|
// ingress is still wiring; `verifying` is the residue: no failure signature
|
|
102
|
-
// matched and nothing durable proves the VM is dead
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
102
|
+
// matched and nothing durable proves the VM is dead; `access-pending` is a VM that
|
|
103
|
+
// is up and rostered but whose IAP policy has no accessors YET, because its
|
|
104
|
+
// detached wiring tail — which applies the grant last — is still running. All
|
|
105
|
+
// three render a calm card and none may use the word "failed". Consumed by
|
|
106
|
+
// holdConvergingBadge and by deriveStatus (server.js) to pick the matching calm
|
|
107
|
+
// status.
|
|
108
|
+
const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying', 'access-pending']);
|
|
106
109
|
|
|
107
110
|
// Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
|
|
108
111
|
// positive proof of death, rather than merely an absence of proof of life?
|
|
@@ -157,6 +160,17 @@ function containerHealthWitness({ reachable, inVmHealthy } = {}) {
|
|
|
157
160
|
return null;
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
// Lift the `Next valid action:` remedy out of a BLOCKED reason so a classified
|
|
164
|
+
// card's recovery line stays the SAME text the producing check already emitted,
|
|
165
|
+
// instead of a second copy of the same instructions that can silently drift from
|
|
166
|
+
// it. Returns '' when the text carries no remedy, in which case the caller's
|
|
167
|
+
// static copy stands on its own.
|
|
168
|
+
function nextValidActionRemedy(text) {
|
|
169
|
+
const lines = String(text || '').split('\n');
|
|
170
|
+
const i = lines.findIndex((l) => /^\s*Next valid action:/.test(l));
|
|
171
|
+
return i === -1 ? '' : lines.slice(i).join('\n').trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
// Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
|
|
161
175
|
// can never drift from the category.
|
|
162
176
|
function buildFailureVerdict(category, headline, recovery, extra) {
|
|
@@ -179,6 +193,9 @@ function buildFailureVerdict(category, headline, recovery, extra) {
|
|
|
179
193
|
// instanceAlive does GCP still list the instance?
|
|
180
194
|
// containerHealthy did the in-VM editor answer /api/health?
|
|
181
195
|
// wiringDeadlineBlown did the add-time wire tail drop its failed marker?
|
|
196
|
+
// wiringInFlight is that tail still RUNNING? Only the IAP read-back arm
|
|
197
|
+
// reads it, to tell "no accessors yet" from "no accessors,
|
|
198
|
+
// ever" — the grant is the tail's last step.
|
|
182
199
|
//
|
|
183
200
|
// Returns { category, headline, recovery, retryable, files? }.
|
|
184
201
|
//
|
|
@@ -188,7 +205,7 @@ function buildFailureVerdict(category, headline, recovery, extra) {
|
|
|
188
205
|
// signature is therefore tested BEFORE the generic cloud-up-failed signature, or
|
|
189
206
|
// a retryable drift would misclassify as a non-retryable cloud:up failure and the
|
|
190
207
|
// operator would lose the safe one-click recovery.
|
|
191
|
-
function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
|
|
208
|
+
function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, wiringInFlight } = {}) {
|
|
192
209
|
const log = String(logTail || '');
|
|
193
210
|
|
|
194
211
|
// 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
|
|
@@ -270,7 +287,37 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
270
287
|
'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
|
|
271
288
|
}
|
|
272
289
|
|
|
273
|
-
// 8.
|
|
290
|
+
// 8. The post-add IAP read-back verdicts. Unlike every signature above, this one
|
|
291
|
+
// is not produced by the provision transcript at all — it is the BLOCKED
|
|
292
|
+
// reason iapReadbackOutcome hands to annotateAddFailure, which is what
|
|
293
|
+
// stops a read-back failure from being the one add failure that reaches the
|
|
294
|
+
// card with no category at all (and so no differentiated headline, no
|
|
295
|
+
// recovery, no guarded Retry, and no chance for holdConvergingBadge to hold it
|
|
296
|
+
// calm). Tested AFTER all seven hard-failure signatures so a proven failure
|
|
297
|
+
// always wins if both texts are ever present, and BEFORE converging/verifying
|
|
298
|
+
// because it is strictly more specific than either.
|
|
299
|
+
if (/IAP policy authorizes no human/i.test(log)) {
|
|
300
|
+
const remedy = nextValidActionRemedy(log);
|
|
301
|
+
// 8a. access-pending — no accessors YET. The grant is the LAST step of the
|
|
302
|
+
// detached wire-add-tail, so while that tail is alive an empty policy is
|
|
303
|
+
// the expected mid-flight reading, not a verdict. Calm: this is the state
|
|
304
|
+
// that used to render a red "Add failed" on healthy VM-4/VM-7 minutes
|
|
305
|
+
// before their grant step had even run (2026-08-11).
|
|
306
|
+
if (wiringInFlight) {
|
|
307
|
+
return buildFailureVerdict('access-pending', 'Access not granted yet',
|
|
308
|
+
'The VM is up and rostered, but its IAP policy has no human accessors yet — the add\'s background wiring applies the grant as its last step and is still running. This resolves on its own once that tail finishes; no action is needed unless it persists past the wiring deadline.');
|
|
309
|
+
}
|
|
310
|
+
// 8b. iap-unauthorized — the wiring is done and the policy STILL authorizes
|
|
311
|
+
// nobody, so the public URL returns "Error code 51" for every human. This
|
|
312
|
+
// is the 2026-08-09 case the read-back exists to catch (one step granted
|
|
313
|
+
// the operator, the next revoked him) and it is a genuine hard failure.
|
|
314
|
+
// Not retryable: the VM exists and is fine — re-adding it would duplicate
|
|
315
|
+
// a live box when what is missing is one grant.
|
|
316
|
+
return buildFailureVerdict('iap-unauthorized', 'No one can reach this VM',
|
|
317
|
+
`The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 9. converging — the add reached the early-roster marker (`CY_ROSTER`): the
|
|
274
321
|
// container gate passed, the instance is real, and the VM is rostered, yet it
|
|
275
322
|
// exited non-zero WITHOUT matching any specific hard-failure signature above.
|
|
276
323
|
// By construction of the provision script, every genuine hard failure (drift,
|
|
@@ -280,15 +327,15 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
280
327
|
// wire-add-tail / reconcile-ingress — NOT an add failure. Surface it as an
|
|
281
328
|
// honest in-flight state instead of the scary red `unknown`. Not retryable:
|
|
282
329
|
// the VM exists and its detached wiring owns finishing it, so a one-click
|
|
283
|
-
// retry could duplicate/orphan a live box. Tested
|
|
284
|
-
// signature (all printed pre-roster) always wins —
|
|
285
|
-
//
|
|
330
|
+
// retry could duplicate/orphan a live box. Tested after every hard-failure
|
|
331
|
+
// signature (all printed pre-roster) so a proven failure always wins —
|
|
332
|
+
// `converging` can never shadow one.
|
|
286
333
|
if (transcriptRostered(log)) {
|
|
287
334
|
return buildFailureVerdict('converging', 'Add still converging',
|
|
288
335
|
'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
|
|
289
336
|
}
|
|
290
337
|
|
|
291
|
-
//
|
|
338
|
+
// 10. The residue: no failure signature above matched. The polarity here is
|
|
292
339
|
// INVERTED from the original design. It used to read "no recognized success
|
|
293
340
|
// signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
|
|
294
341
|
// VM whose container was Up and whose editor answered /api/health 200 got a
|
|
@@ -298,7 +345,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
298
345
|
//
|
|
299
346
|
// Red now requires positive proof of death, and everything else is in-flight:
|
|
300
347
|
//
|
|
301
|
-
//
|
|
348
|
+
// 10a. unknown — a non-zero exit CORROBORATED by a durable fact (see
|
|
302
349
|
// addFailureCorroborated). The box is provably dead or provably never
|
|
303
350
|
// came up, so the loud, terminal escalation is warranted and unchanged.
|
|
304
351
|
// Still never invites an unproven retry.
|
|
@@ -307,7 +354,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
307
354
|
'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
|
|
308
355
|
}
|
|
309
356
|
|
|
310
|
-
//
|
|
357
|
+
// 10b. verifying — the honest default. No failure signature, and nothing
|
|
311
358
|
// durable proves this VM is dead: it may still be provisioning or
|
|
312
359
|
// wiring, and the ingress can keep propagating for minutes after the
|
|
313
360
|
// wrapper is gone. Calm copy, transcript still one click away. NOT
|
package/npm/fleet-memory.js
CHANGED
|
@@ -290,8 +290,16 @@ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
|
|
|
290
290
|
* Degrades to 'cgroup=unavailable' rather than printing zeros off-cgroup, so a
|
|
291
291
|
* laptop log cannot be misread as a cgroup at 0MB.
|
|
292
292
|
*
|
|
293
|
+
* `detachedWiringCount` — when supplied — appends `tails=N`, which is what makes
|
|
294
|
+
* the RSS/cgroup gap ATTRIBUTABLE rather than merely visible. On 2026-08-11 the
|
|
295
|
+
* cgroup read 2154MB against a 96MB RSS and the whole diagnosis was "those ~2GB
|
|
296
|
+
* are this process's own transient add children, not a leak". `RSS=96MB
|
|
297
|
+
* cgroup=2154MB tails=4` reads as a batch draining on its own; `RSS=1300MB
|
|
298
|
+
* cgroup=1400MB tails=0` reads as a genuine leak. Omitted (the pre-existing
|
|
299
|
+
* callers, and any host with no tail accounting) prints exactly as before.
|
|
300
|
+
*
|
|
293
301
|
* Pure; no I/O. */
|
|
294
|
-
function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
|
|
302
|
+
function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh, detachedWiringCount } = {}) {
|
|
295
303
|
const mb = (b) => (Number(b) / 1024 / 1024).toFixed(0);
|
|
296
304
|
const current = Number(memoryCurrent);
|
|
297
305
|
const high = Number(memoryHigh);
|
|
@@ -308,6 +316,8 @@ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
|
|
|
308
316
|
} else {
|
|
309
317
|
parts.push('cgroup=unavailable');
|
|
310
318
|
}
|
|
319
|
+
const tails = Number(detachedWiringCount);
|
|
320
|
+
if (Number.isFinite(tails) && tails >= 0) parts.push(`tails=${tails}`);
|
|
311
321
|
return parts.join(' ');
|
|
312
322
|
}
|
|
313
323
|
|
|
@@ -323,11 +333,26 @@ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
|
|
|
323
333
|
* protecting. The dashboard's own self-update counts, being a heavy job by any
|
|
324
334
|
* other name.
|
|
325
335
|
*
|
|
326
|
-
*
|
|
327
|
-
|
|
336
|
+
* `detachedWiringCount` is the THIRD source of busy, and the one this guard was
|
|
337
|
+
* blind to: an Add settles at container-healthy while its detached wire-add-tail
|
|
338
|
+
* keeps spawning gcloud children for another 5-10 min in the SAME cgroup. Judging
|
|
339
|
+
* "safe to restart" off `state.jobs` alone reported no job in flight while that
|
|
340
|
+
* work was live — on 2026-08-11 the escalation crossed the restart threshold at
|
|
341
|
+
* 86% of memory.high and the only thing that held it back was an unrelated job
|
|
342
|
+
* happening to still be `running`. Had it settled thirty seconds earlier the
|
|
343
|
+
* dashboard would have restarted itself mid-batch and orphaned four adds, which
|
|
344
|
+
* is precisely the failure class the detached-provision work exists to prevent.
|
|
345
|
+
* Omitting the key behaves exactly as before, so every current caller is
|
|
346
|
+
* unaffected.
|
|
347
|
+
*
|
|
348
|
+
* Pure; no I/O — the caller passes state.jobs / state.dashboardUpdate and the
|
|
349
|
+
* live-tail count. */
|
|
350
|
+
function hasJobInFlight({ jobs, dashboardUpdate, detachedWiringCount } = {}) {
|
|
328
351
|
const busy = (j) => !!j && (j.state === 'running' || j.state === 'recovering');
|
|
329
352
|
const anyJob = Object.values((jobs && typeof jobs === 'object') ? jobs : {}).some(busy);
|
|
330
|
-
|
|
353
|
+
const tails = Number(detachedWiringCount);
|
|
354
|
+
const anyTail = Number.isFinite(tails) && tails > 0;
|
|
355
|
+
return anyJob || anyTail || !!(dashboardUpdate && dashboardUpdate.state === 'running');
|
|
331
356
|
}
|
|
332
357
|
|
|
333
358
|
/** Should this escalation level be announced to the journal?
|
package/npm/fleet-queue.js
CHANGED
|
@@ -126,9 +126,150 @@ function reconcileQueueDraft(draftOrder, liveOrder) {
|
|
|
126
126
|
return out;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Consecutive failed reads before the panel stops trusting its mirror. At the
|
|
130
|
+
// default QUEUE_POLL_MS of 5000 this is ~15s of sustained failure — long enough
|
|
131
|
+
// that the ordinary 20s-timeout blip the next tick clears only ever reaches
|
|
132
|
+
// 'degraded', short enough that a real credential outage is flagged in seconds.
|
|
133
|
+
// Named rather than inlined so the verdict and the tooltip copy read the same
|
|
134
|
+
// number from one place.
|
|
135
|
+
const QUEUE_STALE_AFTER_FAILURES = 3;
|
|
136
|
+
|
|
137
|
+
/** Merge one commit-queue poll result over the previous memo.
|
|
138
|
+
*
|
|
139
|
+
* THE RULE: a failed read RETAINS the previous `entries` AND the previous
|
|
140
|
+
* `readAt`, and records `error`. Retaining the entries is deliberate — a stale
|
|
141
|
+
* order beats a blank panel for an operator mid-triage. Leaving `readAt`
|
|
142
|
+
* untouched is what makes the staleness legible: it is the ONLY signal that
|
|
143
|
+
* distinguishes a frozen view from a live one.
|
|
144
|
+
*
|
|
145
|
+
* Advancing `readAt` on a failure is precisely the bug this exists to prevent.
|
|
146
|
+
* On 2026-08-14 /etc/codeyam/git-credentials on the operator host went to 0
|
|
147
|
+
* bytes and every origin git operation began failing; the queue panel went on
|
|
148
|
+
* naming VM-5 as HEAD for 3.5 hours after VM-5 had released, while the real
|
|
149
|
+
* queue was VM-2 -> VM-8 -> VM-4. Nothing on the page distinguished it from
|
|
150
|
+
* live. This is the same lesson mergeDashboardRevision records from
|
|
151
|
+
* 2026-07-31, applied to the second poller.
|
|
152
|
+
*
|
|
153
|
+
* A previous `readAt` of null (no successful read has EVER happened) stays
|
|
154
|
+
* null and is not coerced to a timestamp — "never read" and "read a moment
|
|
155
|
+
* ago" must stay distinguishable, the same reason mergeDashboardRevision
|
|
156
|
+
* refuses to push a null `behind` through Number().
|
|
157
|
+
*
|
|
158
|
+
* Success is the absence of `error`. Pure; no I/O. */
|
|
159
|
+
function mergeQueuePoll({ prev, entries, readAt, error } = {}) {
|
|
160
|
+
const base = (prev && typeof prev === 'object') ? prev : {};
|
|
161
|
+
const str = (v) => ((typeof v === 'string' && v) ? v : null);
|
|
162
|
+
const prevEntries = Array.isArray(base.entries) ? base.entries : [];
|
|
163
|
+
const prevReadAt = str(base.readAt);
|
|
164
|
+
const prevFailures = (typeof base.consecutiveFailures === 'number'
|
|
165
|
+
&& Number.isFinite(base.consecutiveFailures) && base.consecutiveFailures >= 0)
|
|
166
|
+
? base.consecutiveFailures : 0;
|
|
167
|
+
const err = str(error);
|
|
168
|
+
if (err) {
|
|
169
|
+
return {
|
|
170
|
+
entries: prevEntries,
|
|
171
|
+
readAt: prevReadAt,
|
|
172
|
+
error: err,
|
|
173
|
+
consecutiveFailures: prevFailures + 1,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
entries: Array.isArray(entries) ? entries : [],
|
|
178
|
+
readAt: str(readAt) || prevReadAt,
|
|
179
|
+
error: null,
|
|
180
|
+
consecutiveFailures: 0,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What does the current queue memo mean for the operator?
|
|
185
|
+
*
|
|
186
|
+
* Levels:
|
|
187
|
+
* 'fresh' — the last read succeeded. Render exactly as before; every
|
|
188
|
+
* control stays live.
|
|
189
|
+
* 'degraded' — erroring, but under QUEUE_STALE_AFTER_FAILURES consecutive
|
|
190
|
+
* failures. A blip. Noted quietly, controls stay live — a
|
|
191
|
+
* single 20s-timeout hiccup that the next tick clears must not
|
|
192
|
+
* cry wolf or lock the operator out of their own queue.
|
|
193
|
+
* 'stale' — erroring at or past the threshold. The mirror is not to be
|
|
194
|
+
* trusted and writes computed against it are blocked.
|
|
195
|
+
* 'unknown' — erroring and NO read has ever succeeded (the boot window with
|
|
196
|
+
* broken credentials). Worth saying out loud: an empty-looking
|
|
197
|
+
* queue here means "we cannot see the queue", not "go".
|
|
198
|
+
*
|
|
199
|
+
* AGE IS DELIBERATELY NOT AN INPUT. A genuinely quiet queue is not a broken
|
|
200
|
+
* one, so escalation keys on consecutive read FAILURES only; age is carried by
|
|
201
|
+
* projectQueueFreshness purely to phrase the message ("last read 3h 12m ago").
|
|
202
|
+
* An hours-old `readAt` with no error is 'fresh' and must stay that way. Taking
|
|
203
|
+
* a clock here would imply otherwise, so the signature omits one — the same
|
|
204
|
+
* take-only-what-you-use shape as classifyDashboardStaleness.
|
|
205
|
+
*
|
|
206
|
+
* Pure; no I/O. */
|
|
207
|
+
function classifyQueueFreshness({ readAt, error, consecutiveFailures } = {}) {
|
|
208
|
+
if (!(typeof error === 'string' && error)) return 'fresh';
|
|
209
|
+
if (!(typeof readAt === 'string' && readAt)) return 'unknown';
|
|
210
|
+
const failures = (typeof consecutiveFailures === 'number' && Number.isFinite(consecutiveFailures))
|
|
211
|
+
? consecutiveFailures : 0;
|
|
212
|
+
return failures >= QUEUE_STALE_AFTER_FAILURES ? 'stale' : 'degraded';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The `/data` view of the queue memo, with the freshness VERDICT resolved
|
|
216
|
+
* server-side.
|
|
217
|
+
*
|
|
218
|
+
* The panel is then a pure renderer. Two consumers re-deriving "is this stale"
|
|
219
|
+
* from a raw timestamp would drift, and `public/index.html` is an inline
|
|
220
|
+
* browser script with no module boundary — any decision left in it could only
|
|
221
|
+
* ever be source-scan tested, which is how it ships bugs green. Same split, and
|
|
222
|
+
* same reason, as projectDashboardRevision.
|
|
223
|
+
*
|
|
224
|
+
* `now` is injected (epoch ms) rather than read from the clock so the
|
|
225
|
+
* projection stays pure and its tests deterministic. `ageSec` is null when no
|
|
226
|
+
* successful read has happened or no clock was supplied.
|
|
227
|
+
*
|
|
228
|
+
* Pure; no I/O. */
|
|
229
|
+
function projectQueueFreshness(memo = {}, now = null) {
|
|
230
|
+
const at = (typeof memo.readAt === 'string' && memo.readAt) ? Date.parse(memo.readAt) : NaN;
|
|
231
|
+
const clock = (typeof now === 'number' && Number.isFinite(now)) ? now : null;
|
|
232
|
+
const ageSec = (Number.isFinite(at) && clock !== null)
|
|
233
|
+
? Math.max(0, Math.floor((clock - at) / 1000))
|
|
234
|
+
: null;
|
|
235
|
+
return { ...memo, level: classifyQueueFreshness(memo), ageSec };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The operator-facing reason a queue WRITE must be refused, or null when writes
|
|
239
|
+
* are allowed.
|
|
240
|
+
*
|
|
241
|
+
* A write computed against a mirror known to be frozen is never the right
|
|
242
|
+
* action, and the silent-no-op outcome is worse than a refusal: a Release
|
|
243
|
+
* fires at a uuid that no longer holds the head, reports "released head
|
|
244
|
+
* bb65addf", changes nothing, and leaves the real head held. A reorder is
|
|
245
|
+
* worse still — computeReorderTargets validates the posted order against the
|
|
246
|
+
* very mirror that is wrong, so a doomed reorder looks perfectly valid.
|
|
247
|
+
*
|
|
248
|
+
* The verbatim git error is interpolated rather than summarised to "poll
|
|
249
|
+
* failed". On 2026-08-14 the entire recovery was legible from one line —
|
|
250
|
+
* `could not read Username for 'https://github.com'` — and truncating it would
|
|
251
|
+
* have cost the same 3.5 hours.
|
|
252
|
+
*
|
|
253
|
+
* Lives here rather than in server.js so the refusal COPY is directly
|
|
254
|
+
* assertable: server.js has top-level side effects on require, so a message
|
|
255
|
+
* built there could only ever be source-scan tested. Pure; no I/O. */
|
|
256
|
+
function queueWriteBlockReason(memo = {}) {
|
|
257
|
+
const level = classifyQueueFreshness(memo);
|
|
258
|
+
if (level !== 'stale' && level !== 'unknown') return null;
|
|
259
|
+
const why = (typeof memo.error === 'string' && memo.error) ? ` (${memo.error})` : '';
|
|
260
|
+
return level === 'unknown'
|
|
261
|
+
? `commit queue has never been read successfully${why} — cannot act on an unknown queue`
|
|
262
|
+
: `commit queue read is failing${why} — the displayed order cannot be trusted; fix the read before changing the queue`;
|
|
263
|
+
}
|
|
264
|
+
|
|
129
265
|
module.exports = {
|
|
130
266
|
decideAutoReleaseGoneHead,
|
|
131
267
|
computeReorderTargets,
|
|
132
268
|
nudgeQueueOrder,
|
|
133
269
|
reconcileQueueDraft,
|
|
270
|
+
mergeQueuePoll,
|
|
271
|
+
classifyQueueFreshness,
|
|
272
|
+
projectQueueFreshness,
|
|
273
|
+
queueWriteBlockReason,
|
|
274
|
+
QUEUE_STALE_AFTER_FAILURES,
|
|
134
275
|
};
|